Skip to content

perf(qwp): improved Gorilla timestamp decoding - #69

Open
glasstiger wants to merge 7 commits into
mainfrom
ia_gorilla_decoding
Open

glasstiger wants to merge 7 commits into
mainfrom
ia_gorilla_decoding

Conversation

@glasstiger

@glasstiger glasstiger commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Gorilla-encoded TIMESTAMP, TIMESTAMP_NANOS, and DATE columns in query results now decode without BigInt arithmetic, in packages/client-core/src/_qwp/_core/result-batch.ts. In testing, a QuestDB nightly server flagged every result batch as Gorilla-encoded, so this path decodes the timestamp columns of typical query results in both @questdb/nodejs-client and @questdb/browser-client. No public API change, and decoded values and errors are unchanged.

  • One decoder for both paths. decode() and decodeView() each held a copy of a loop that read the bitstream one bit at a time and built every delta-of-delta from per-bit BigInt operations (value |= 1n << BigInt(bit)). Both now call decodeGorillaTimestamps(), which reads through a 32-bit bit buffer, carries the int64 delta and timestamp as int32 halves with explicit carries (wrapping exactly as BigInt.asIntN(64, ...) does), and emits runs of repeated deltas in one step.
  • Little-endian output on every host. Values are stored as int32 word pairs through an Int32Array, which ran the decode loop 1.8-4.7x faster than DataView stores in V8. A byte-swap pass runs only on big-endian hosts.
  • Earlier truncation check. A stream too short to hold one bit per row is rejected before any storage is sized for it; the view path used to allocate first. The error is unchanged (truncated QWP Gorilla bitstream).
  • Retained decode buffer for decode(). One buffer per decoder is reused across batches, as column views already did.
  • BigInt64Array read-out. On little-endian hosts, decode() reads the values out of that buffer through a BigInt64Array. Read through DataView.getBigInt64, materialized constant-interval batches ran at 0.72x (Node 20) and 0.76x (Node 22) the speed of main. Big-endian hosts keep the DataView, because a BigInt64Array reads native byte order, in a loop deliberately separate from readInt64Values(): with one loop serving both, V8 ran this path up to 2x slower, depending on which caller it optimized first.
  • Tests: every delta-of-delta width at both bucket edges, NULLs, int64 edges and low-word carries, and decoder reuse, through decode() and decodeView(); 600 random streams (including truncated ones and int64 wraparound) against a bit-at-a-time BigInt reference decoder, comparing values, consumed bytes, and errors; every malformed-column error through both paths; a simulated big-endian host (the module's endianness probe and BigInt64Array reads made big-endian), which expects byte-swapped values on a little-endian host and the true values on a big-endian one.
  • Benchmarks: timestamp-only Gorilla arms in benchmarks/egress.bench.ts (constant, jittered, and irregular intervals). benchmarks/workloads.ts exports its existing xorshift generator for them.

Performance

Diagnostic, not CI gates. Before is main; two interleaved runs each, averaged.

Node 24.3.0, benchmarks/egress.bench.ts, one Gorilla TIMESTAMP column of 10k rows, batches/s:

Timestamps column view: main this PR materialized: main this PR
constant 1 ms 19,392 63,505 (3.3x) 13,258 23,704 (1.8x)
1 ms, ±50 µs jitter 891 14,785 (16.6x) 937 10,570 (11.3x)
irregular, mean 1 s 282 9,142 (32.4x) 277 7,254 (26.1x)

The same arms on Node 20.18.1 and 22.17.0, main → this PR:

Timestamps Node 20: column view Node 20: materialized Node 22: column view Node 22: materialized
constant 1 ms 7,995 → 51,858 (6.5x) 12,547 → 17,903 (1.4x) 7,529 → 60,054 (8.0x) 12,680 → 20,598 (1.6x)
1 ms, ±50 µs jitter 887 → 14,171 (16.0x) 921 → 9,209 (10.0x) 834 → 15,341 (18.4x) 936 → 10,157 (10.8x)
irregular, mean 1 s 266 → 8,426 (31.6x) 262 → 6,431 (24.5x) 273 → 9,129 (33.5x) 267 → 7,127 (26.7x)

The existing mixed batch (INT, DOUBLE, VARCHAR, constant-interval Gorilla TIMESTAMP): reusable column views 16,025 → 37,560 batches/s on Node 24 (2.3x; 4.1x on Node 20 and 5.2x on Node 22), decode() materialized 881 → 926 (1.05x; 1.03x and 1.04x). The traversal arms decode the same batch, Gorilla column included, on every iteration: within 1% on Node 24 and 2-8% faster on Node 20 and 22. The Zstd arm does not exercise this code and is unchanged within noise.

JavaScriptCore (Bun 1.0.14, Safari's engine), the built browser bundle, the same three interval shapes, 10k rows, median µs per batch:

Timestamps column view: main this PR materialized: main this PR
constant 1 ms 896 12.4 (72x) 520 186.5 (2.8x)
1 ms, ±50 µs jitter 4,049 61.5 (66x) 6,491 253.7 (26x)
irregular, mean 1 s 11,888 110.8 (107x) 17,782 333.8 (53x)

These JavaScriptCore figures predate the BigInt64Array read-out, which changes only the materialized path: in a separate harness it made materialized decoding in Bun a further 1.03-1.09x faster.

The gain is larger in JavaScriptCore because it allocates on every BigInt operation, while V8 lowers BigInt.asIntN(64, ...) arithmetic to native int64 math. Materializing a bigint[] still costs one BigInt allocation per value, which bounds the materialized speedup.

Validation

  • pnpm exec vitest run (1,145 tests, including container integration), pnpm test:dist, pnpm test:qwp-browser
  • pnpm typecheck, pnpm typecheck:test, pnpm typecheck:qwp-browser, pnpm typecheck:bench, pnpm typecheck:dist
  • pnpm eslint, pnpm lint:bench, pnpm format:check, pnpm check:packages
  • Mutation check: 18 decoder faults injected one at a time (dropped carries, lost sign extension, an unclamped zero run, a short refill, removed or off-by-two truncation checks, rounded-down consumed bytes, wrong output lengths, a byte swap on a little-endian host), plus 4 in the read-out (a dropped little-endian check, an unsigned read, a big-endian read in the DataView fallback, an off-by-one index). The new tests fail for each.
  • Against a QuestDB 10.0.2-SNAPSHOT server (nightly image): 300k-row queries over constant, jittered, and irregular designated timestamps, an unordered non-designated TIMESTAMP, TIMESTAMP_NS, and DATE. Every batch was Gorilla-flagged; on Node 20.18.1, 22.17.0, and 24.3.0 this PR decoded the same values as main through query() and queryViews(), and the first and last rows matched HTTP /exec.
  • Big-endian: on s390x Node 20.20.2 under QEMU, every Gorilla test passes, including the 600-stream reference, and dropping the little-endian read-out check fails four of them. The other failures there also fail on main: six Zstd tests and one test that exceeds its timeout under emulation.
  • The built browser bundle in headless Chromium, and the same code in Bun, decode constant, jittered, and irregular streams to the expected values.
  • Not exercised: Firefox.

decode() and decodeView() each held a copy of a Gorilla loop that read
the bitstream one bit at a time and built every delta-of-delta from
per-bit BigInt operations. Both now call one decoder that reads through a
32-bit bit buffer, carries the int64 delta and timestamp as int32 halves
with explicit carries, wrapping exactly as BigInt.asIntN(64, ...) does,
and emits runs of repeated deltas in one step. Values are stored as int32
word pairs through an Int32Array, with a byte swap on big-endian hosts so
the bytes stay little-endian. Decoded values and errors are unchanged.

A stream too short to hold one bit per row is now rejected before any
storage is sized for it, and decode() reuses one decode buffer per
decoder across batches, as column views already did.

On 10,000-row batches in Node 24, column views decode 3.2-32x faster and
materialized batches 1.8-27x faster, least for a constant interval and
most for irregular timestamps. JavaScriptCore views decode 66-107x faster.

Adds code-width, NULL, int64-edge, and decoder-reuse tests for both
paths, a 600-stream comparison against a bit-at-a-time BigInt reference
decoder, every malformed-column error, and timestamp-only Gorilla arms in
benchmarks/egress.bench.ts.
The code-width test reused one decoder only with shorter batches, so
nothing exercised growing the retained Gorilla storage: a decoder that
stopped growing it passed every test while a larger later batch threw a
RangeError. A three-value batch now precedes the main frame, which has
to grow that storage in both decode() and decodeView().

The test's `max` column claimed to cover the top of the int64 range but
peaked about 7.1e11 below INT64_MAX. It now ends exactly at INT64_MAX.
The Gorilla decoder writes int32 words through an Int32Array and
byte-swaps them on big-endian hosts, so column views still expose
little-endian int64 bytes. No test reached that branch:
NATIVE_LITTLE_ENDIAN is fixed when result-batch.ts loads and every CI
host is little-endian, so deleting the swap call left all 897 QWP tests
passing.

The new test makes the module's Uint16Array.of(1) probe see big-endian
storage, loads a fresh copy, and decodes through decode() and
decodeView(). On a little-endian host the swap then shows as
byte-reversed int32 halves in every value. It fails if the swap call is
deleted or inverted, or if the swap drops a byte, shifts one wrong, or
covers only half the words.
The decoder's comment said the speedup was least for a constant interval
and most for irregular timestamps, in V8 and JavaScriptCore alike. In
JavaScriptCore the jittered series gains least: two Bun 1.0.14 runs
measured 66x and 61.7x for it, against 72x and 72.5x for a constant
interval. The ordering now applies to V8 only, and the JavaScriptCore
figure reads "over 60x" rather than a 65-110x range that the second run
fell below.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Level 3 review — approve (head cc1dc99766c1d69128b7a63f60919d1a896ad678).

No admitted findings: Critical 0, Moderate 0, Minor 0; in-diff 0, out-of-diff breakage 0. Test gate passes with 0 admitted coverage gaps. The full suite passed (1,127 tests), along with package-boundary and browser suites, typechecks, lint, and formatting checks. Base/head benchmarks reproduced the claimed Gorilla decoding speedup. The decoder retains row-cap-bounded scratch storage; no regression was observed.

Submodules: none.

@glasstiger

Copy link
Copy Markdown
Collaborator Author

Level 3 review — approve (head cc1dc99, base a658130)

No findings in this PR: Critical 0, Moderate 0, Minor 0. The test gate passes with no coverage gaps worth reporting.

Evidence

  • Values and errors unchanged. About 160k random Gorilla-flagged RESULT_BATCH frames were decoded by both base and head builds, on Node 24 and on Bun 1.0.14 (JavaScriptCore). The frames covered NULL bitmaps, raw fallback, unknown encodings, truncated and garbage streams, int64 edges, and decoder reuse. decode() values, decodeView() values and valuesBytes(), materialize(), and error class and message were identical in every case: about 57.5k frames decoded, and the rest failed with the same error on both.

  • Speedups reproduce. Node 24, 10k rows, base → head:

    Path constant jittered irregular
    column views 3.1x 15.2x 32.1x
    decode() 1.8x 10.9x 26.3x

    On Bun (JavaScriptCore), column views were 79–173x faster.

  • Tests. Every injected decoder fault that changed decoded values or the bytes consumed was caught by the new tests.

  • Checks at head, all passing: every tsc typecheck including the built .d.ts files (also under TypeScript 4.9), eslint, lint:bench, prettier, vitest test/qwp (898 tests), test:dist, check:packages, and test:qwp-browser (Chromium).

  • Not run: the container integration suite (it covers ILP ingress, not this decoder), Firefox, and a real big-endian host.

Tradeoff: decode() now keeps one scratch buffer per decoder: 128 KiB after a 10k-row column, at most 8 MiB at the 1,048,576-row cap, and lower with maxBatchRows. The PR states this, and it follows the existing view-path scratch.

Pre-existing bug (not from this PR, not blocking; worth its own issue)

QwpResultBatchDecoder.resetQuerySchema() throws a TypeError once decodeView() has used a slot above a never-used lower slot. The loop at result-batch.ts:1578 calls release() on an array hole, while releaseView() at line 1618 already guards with ?.. Egress sessions hand out slots in order from 0, so only direct users of the public decoder can hit it.

const decoder = new QwpResultBatchDecoder();
decoder.decodeView(message, 1); // slot 0 never used
decoder.resetQuerySchema(); // TypeError: Cannot read properties of undefined (reading 'release')

The schema and batch-sequence reset after the loop then never run. Reproduced on base and head (Node 24, Bun 1.0.14). Suggested fix: batch?.release(), plus a public-API test that decodes into slot 1 and then resets.

Submodules: none.

@glasstiger glasstiger changed the title perf(qwp): decode Gorilla timestamps without BigInt perf(qwp): improve efficiency of Gorilla timestamps decoding Oct 2, 2026
@glasstiger glasstiger changed the title perf(qwp): improve efficiency of Gorilla timestamps decoding perf(qwp): improved Gorilla timestamps decoding Oct 2, 2026
@glasstiger glasstiger changed the title perf(qwp): improved Gorilla timestamps decoding perf(qwp): improved Gorilla timestamp decoding Oct 2, 2026

@javier javier left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested and I can confirm it sped up when the bottleneck was decoding (in my case, with a client running in the same VPC as the server on EC2)

javier
javier previously approved these changes Oct 5, 2026
glasstiger and others added 3 commits October 10, 2026 14:14
The big-endian Gorilla test makes the module's Uint16Array.of(1) probe
report big-endian storage, then expected the byte-reversed int32 halves
that a little-endian host decodes. On a real big-endian host the swap is
the actual conversion, so both decoders return the true values: the test
failed on s390x Node 20, 22, and 26 under QEMU, and passed there only
with the swap deleted.

It now expects the swapped values on a little-endian host and the true
values on a big-endian one, where a missing swap fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decode() read every decoded Gorilla timestamp out of its int32 words
with DataView.getBigInt64, which is slow in the V8 of Node 20 and 22:
there, a constant-interval timestamp column decoded at 0.7-0.8x the
speed of main for 1,000-10,000 rows. On little-endian hosts the words
are now read through a BigInt64Array, which makes decode() 1.4-1.9x as
fast as main on Node 20.18.1 and 22.17.0 and leaves Node 24 unchanged.
Big-endian hosts keep the DataView, because a BigInt64Array reads
native byte order.

The big-endian test now also gives the simulated copy a big-endian
BigInt64Array while it decodes. Without it, removing the little-endian
check passed every test on a little-endian host; on s390x that change
fails four Gorilla tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glasstiger

glasstiger commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Level 3 review — approve (head b62b51d, base 87cd627)

No admitted findings: 0 Critical, 0 Moderate, 0 Minor; 0 in-diff, 0 out-of-diff breakage. The test gate passes with 0 admitted coverage gaps.

Evidence

  • Values and errors are unchanged. Base and head decoded the same frames through decode() and decodeView(), with zero mismatches. The comparison covered:

    • values through getLong(), get() and materialize(), plus valuesBytes();
    • bytes consumed, checked by a trailing column;
    • error class and message.
  • Frames compared:

    • about 1.3M random and systematic frames on Node 20, 22 and 24 (also with --jitless and --always-turbofan), Bun (JavaScriptCore), real Zstd bodies, Buffer inputs with a non-zero byteOffset, and s390x;
    • every 0–2-byte bitstream for counts 3–26 (4.7M frames);
    • 2,500 frames from a port of the server's Gorilla encoder, including wrapped delta-of-deltas that the JS encoder refuses;
    • 25k truncation cuts.
  • Error handling: no new exception type escapes, so egress-session recovery, which catches only QwpProtocolError, is unaffected.

  • Wire format matches the QuestDB server encoder (QwpGorillaEncoder and QwpResultBatchBuffer.emitTimestampSlice, questdb/questdb@77c6cf2d6b) and the Java client's QwpGorillaDecoder (questdb/java-questdb-client@a7e7db3d).

  • Big-endian: on s390x Node 20.20.2 under QEMU, all 6 Gorilla tests pass at head. The other 7 failures are the known ones that also fail on main: 6 Zstd tests and 1 emulation timeout.

  • Tests: faults were injected into the decoder and the read-out on both a little-endian host and s390x. Every fault that changes decoded values or bytes consumed made a test fail. That includes removing or inverting the little-endian guard and the byte swap.

  • Performance: the PR's multipliers reproduce within about 15%. These are medians of interleaved runs on a loaded machine; "Node 20" is 20.11.0, which has the same V8 as the 20.18.1 floor. Ratios are head/base for one 10k-row Gorilla TIMESTAMP column, at constant / jittered / irregular intervals:

    Path Node 24 Node 22 Node 20 Bun
    column view 3.1x / 15.6x / 31.6x 8.0x / 15.6x / 33.1x 6.4x / 15.0x / 31.7x 45x / 71x / 179x
    decode() 1.6x / 11.4x / 25.7x 1.6x / 10.7x / 27.2x 1.3x / 9.7x / 24.8x 2.9x / 28x / 71x

    With the read-out switched back to DataView, materialized constant intervals ran at 0.65x (Node 20) and 0.72x (Node 22) the speed of main, which supports the BigInt64Array change. The existing mixed-batch arms are about 1.0x materialized and 2.2–5.1x for views.

  • Checks passing at head: vitest run test/qwp (916 tests); typecheck, typecheck:test, typecheck:bench, typecheck:qwp-browser; eslint, lint:bench, format:check, Prettier on benchmarks; test:dist (39), check:packages, test:qwp-browser (10, Chromium).

  • Not exercised: Firefox, Safari itself (JavaScriptCore was covered only through Bun), and throughput on real big-endian hardware.

Tradeoff (stated in the PR): decode() now keeps one Gorilla scratch buffer per decoder, which means one per egress session. It is 128 KiB at the server's 16,384-row batch cap (QwpEgressUpgradeProcessor.MAX_ROWS_PER_BATCH), can reach 8 MiB at the client's 1,048,576-row cap, and maxBatchRows lowers it. Base kept nothing for decode(); the view path already keeps the same amount per slot and per column.

Pre-existing bug (not from this PR, not blocking; fixed in #71)

  • Problem: QwpResultBatchDecoder.resetQuerySchema() crashes when view slots are sparse. It is the bug noted in the earlier review, unchanged at this head.
  • Net impact: direct users of the public decoder whose first view uses slot ≥ 1. After the crash, every later resetQuerySchema() throws again and the decoder rejects the first batch of every later query; only a new decoder recovers.
  • Location: packages/client-core/src/_qwp/_core/result-batch.ts:1578. releaseView() at :1618 already guards with ?..
  • Symptom/reachability:
    • Calling decodeView(message, 1) on a fresh decoder and then resetQuerySchema() throws TypeError: Cannot read properties of undefined (reading 'release').
    • The throw skips the schema and sequence reset, so the next query's batch 0 fails with unexpected RESULT_BATCH sequence [expected=1, actual=0].
    • It reproduces identically at base and head on Node 24 and Bun.
    • QwpEgressSession can't reach it: each query's view slots start as [0..n-1] and are handed out by shift() (egress-session.ts:407-409, :562).
  • Fix: feat(qwp): align ACK waits, reconnect and startup with other QuestDB clients #71 (still open) fixes it in 34221d2. The loop becomes batch?.release(), and the new test "resets query state when the only decoded view occupies slot 1" decodes into slot 1, resets, then decodes the next query's batch 0. At feat(qwp): align ACK waits, reconnect and startup with other QuestDB clients #71's head 2b2b12b, that test passes, and it fails with the same TypeError once the ?. is removed. No separate issue is needed.
  • Standalone severity: Moderate.

PR metadata: the title follows Conventional Commits and the description states the user impact.

Submodules: none.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants